Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857
Open
ondrejmirtes wants to merge 47 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 47 commits into
ondrejmirtes wants to merge 47 commits into
Conversation
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
3 times, most recently
from
June 12, 2026 17:15
ebb19a0 to
457689b
Compare
staabm
reviewed
Jun 12, 2026
| return $this->withFlavor(false); | ||
| } | ||
|
|
||
| private function withFlavor(bool $fiber): self |
Contributor
There was a problem hiding this comment.
should this read withFiber?
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
2 times, most recently
from
June 19, 2026 11:44
eb31077 to
59cbf22
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
June 20, 2026 11:56
59cbf22 to
125cf22
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
4 times, most recently
from
July 6, 2026 22:20
f98892f to
4455baa
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
July 16, 2026 14:56
61fe06e to
e38aadd
Compare
ondrejmirtes
referenced
this pull request
Jul 23, 2026
Every property fetch / method call resolves its type by walking down to the chain root to detect a nullsafe operator (NullsafeShortCircuitingHelper), costing O(N²) walk steps per chain of depth N — with or without an actual nullsafe operator in the chain. Deep loop-wrapped plain chains make that walk dominate: 3.71s -> 3.14s wall (-15%), -18% user CPU from the recursion-to-loop rewrite. The real-world counterpart is Symfony TreeBuilder fluent chains (300+ calls in one statement) in Sylius bundle Configuration classes, which dropped up to 23% per file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016szvNF5RXhACdfMQNc6DVL
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
12 times, most recently
from
July 28, 2026 17:31
fb22d34 to
84b1614
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
August 14, 2026 18:41
98028f1 to
613afb6
Compare
Rules and DependencyResolver receive a node's callback and immediately ask about the node or its subexpressions. Under fibers a pre-order callback parks on its first ask and resumes when the natural walk stores the result anyway - but a synchronously invoked callback (the plain resolver on PHP < 8.1) re-walked everything it asked about through the on-demand bridge: ~380k re-walks during self-analysis, +15% user CPU vs fibers. Expression nodes now emit their callback right after the handler's result is stored, and the expression-carrying statements (echo, return, expression statements) after their expressions are processed - in both cases with the scope captured at the entry position, so rules observe the same (scope, answer) pair as before. Self-analysis on the plain resolver drops from 470k to 107k on-demand walks; fibers are unchanged.
…lts are stored Continues the previous commit for the remaining synchronous-callback re-walk clusters: if/elseif/switch emit their statement callback right after the condition's result is stored (rules like the constant-condition and boolean-in-condition helpers ask about the condition), and prepareTarget() emits the raw assignment target's callback after the walk composed and stored the target's read result (DependencyResolver and the property rules ask about the target and its receiver). Scopes stay captured at the entry position. Self-analysis on the plain resolver drops from 107k to 79k on-demand walks - 14.6k of them on real nodes, down from 380k before the two commits.
…h callback The constant-condition rules listening on BooleanAndNode/BooleanOrNode ask about the raw binary expression, and foreach rules about the iteratee. The boolean handlers now store their result before emitting the virtual node (the later store in processExprNodeInternal is an idempotent re-store of the same result), and the foreach statement emits its callback after the iteratee's result is stored, with the entry scope.
Two diffuse per-event costs measured against the plain resolver: the store hook called processPendingFibersForRequestedExpr() for millions of stores although almost none have a pending fiber - an inline empty check skips the call and the object-id lookup; and gatherers received a FiberScope although they are engine code that never asks about types - they get the raw scope now, and the scopes they capture answer later asks through the storage hub like any MutatingScope.
…ensions Extends the argument priming to the two remaining lazily-invoked extension surfaces: the dynamic static-method return type extensions dispatched for constructors in NewHandler's exactInstantiation() (runs in the typeCallback), and the function/method/static-method type-specifying extensions (run at narrowing-apply time in the specifyTypesCallback). Both can ask Scope::getType() about the call's arguments after the walk's storage frame is no longer current; the primed storage answers those asks from the argument results instead of re-walking on demand. The eager surfaces (throw-type and parameter-out extensions) run during the handler with the walk storage current and need no priming.
OutputBufferHelper priced the incremented ob_get_level() type by walking a synthetic Plus of two TypeExprs through Scope::getType() - a core-engine synthetic re-walk. It is now a service that calls InitializerExprTypeResolver::getPlusType() on the operand types directly.
Two more core synthetic re-walks replaced by the logic they were fishing for: StaticCallHandler priced `new $classExpr` through Scope::getType() to learn what a class-string receiver instantiates - that is getObjectTypeOrClassStringObjectType() on the receiver's own result; and FuncCallHandler's clone-with support walked a synthetic Clone_ although the object argument was just processed - CloneHandler's type logic is now an extracted resolveCloneType() both call sites share.
The static-call promoted-properties check priced $this through a synthetic Variable walk - it is a plain scope-state read. The parent-instantiation synthetic New_ walk in exactInstantiation() stays: it re-resolves the parent constructor's template types from the arguments, which a direct recursion cannot - now documented at the site.
…ider Resolving an unqualified name probes the namespaced variant first, and a miss surfaces as a constructed-and-thrown IdentifierNotFound inside the reflector - repeated for every re-ask of the same name. The single-pass engine's per-flavour callbacks re-ask the same names many times per file (2,500 exception throws while analysing ConstantArrayTypeTest alone). The resolution is now memoized per (namespace, name as written); the key keeps the asked case because the resolved name preserves it for the incorrect-case rules.
The processArgs() restructure lost two things the pre-ArgsResult shape had: the resolved acceptor was selected (and generic-resolved) for every call although a single template-free acceptor IS the resolved acceptor - the fast path the original selectFromArgs() took - and the per-argument type-driven predicate re-traversed the acceptor's parameter types on every argument instead of once per call. Restoring both cuts GenericParametersAcceptorResolver::resolve from 5,175 to 648 calls while analysing ConstantArrayTypeTest.
A rule asking the type of a virtual node itself (BooleanOrNode, ...) parks its fiber - the node is never stored - and the flush walks it on demand, hitting processExprNodeInternal()'s unhandled-expr throw and aborting the whole file's analysis with an internal error. MutatingScope::resolveType() already answers such nodes with mixed; processExprOnDemand() now takes the same fallback, keeping the main walk's throw for real source nodes.
… state A rule callback may derive the scope it was handed - e.g. assignExpression() pinning a call-site literal onto a parameter variable, the way callback- analysing tooling re-analyses a callee body via the public processNodes() API with more specific argument types. FiberScope's settled-result fast path and post-suspend read returned the naked walk-position type, ignoring such derivations. Both now consume through askScopeVariableStateMatches() in a rule-facing mode: variables unknown to the asking scope and variables narrower at the evaluation position (the coalesce right side priced on the left's falsey branch) leave the walk answer standing; an asker-side refinement re-prices on the asking scope's state. MutatingScope::toFiberScope() seeds the created scope with its origin (a WeakReference - a strong back-reference would cycle with the $fiberScope cache and never free with GC disabled), so toMutatingScope() answers with the walk scope itself and the guard's beforeScope identity check hits for same-position asks.
…ad path Old-world resolveType() ran the extension hook on every ask, both flavours. The single-pass engine consults it in ExpressionResult::getType() but not in getTypeOnScope() - the read an assignment fills the target's holder from - nor in getNativeType(), and eager types short-circuited before the hook. An extension's override (phpstan-doctrine's ReturnQueryBuilderExpressionType- ResolverExtension rewriting a method-returned QueryBuilder into its branch type) never entered the scope state, so every downstream chain read saw the raw declared type. All three read paths now consult the extensions first, positioned at the read's scope.
hasOffsetValue(n, T) on a list proves indices 0..n exist, so popping the highest index keeps hasOffset(0..n-1) - previously the accessory answered with no opinion and the whole intersection degraded to a possibly-empty list, so a second array_pop() in the same statement typed as nullable. Shifting reindexes, so the value known at n moves to n - 1 intact. TemplateType members pass through unchanged, as in intersectTypesPreserveTemplateType().
Consulting the extensions on every read re-ran phpstan-doctrine's ReturnQueryBuilderExpressionTypeResolverExtension - which resolves the receiver's method reflection - on each read of each call-typed result, costing several percent of user CPU on projects registering such an extension. One full-null round settles the decline for the result; a non-null answer stays live and is re-derived at the read's scope.
Post-order emission stores the node's own result and every subnode result before the callback fires, so FiberScope answers every ask from the storage: settled results through the asking-scope guard, filter-derived and promoted asks by re-reading on the result's before-scope, unstored asks (synthetic nodes, nodes ahead of the walk) on demand through the MutatingScope path. Node callbacks run directly - no fiber creation, parking, or flushing. The dead fiber machinery is removed separately.
A rule may pass the scope it was handed - the rule-facing FiberScope - as the initial scope of a processNodes()/processStmtNodes() walk (shipmonk's ForbidCheckedExceptionInCallableRule re-walks callable bodies this way). The walk then anchors its results to fiber scopes, and consuming such a result from a filter-derived ask re-enters the rule-facing ask paths, deriving scopes without end. The public entry points now normalize to the state-identical MutatingScope, and preprocessScope() guards the consumption side the same way.
Nothing suspends since node-callback asks are answered synchronously: the fiber pool (parked/pending fiber arrays on the storage and their native mirror), the park/resume driver, the end-of-scope flush with its on-demand memo and state snapshots, and the processing-expression tracking that existed to delay flushes all go. processStmtNodesInternal folds into its only remaining variant.
FiberNodeScopeResolver's only remaining behavior - unwrap gatherers, skip noop callbacks, hand rules the FiberScope - moves into the base resolver, and the class goes together with the FnsrExtension autowiring switch, the PHPSTAN_FNSR toggle, and the PHP 8.1 gate: nothing here needs fibers anymore, so 7.4 and 8.0 get the same storage-backed rule scope with walk-position answers as everything else. Test expectations that keyed on the resolver split become unconditional.
Nothing about the class is fiber-specific anymore: it is the scope every node callback receives, answering asks from the walk's stored expression results. The Fiber namespace dissolves (the resolver override moved to the base class earlier), toFiberScope() becomes toNodeCallbackScope(), the scope factory flavour flag says what it creates, and the fnsr.php type-inference fixture moves under nsrt/ auto-discovery, making its dedicated test class redundant.
The walk scope is what the conversion produces: the engine-facing MutatingScope behind a NodeCallbackScope. The scope factory counterpart becomes toWalkScopeFactory(), the callback scope's cached conversion and seed follow (walkScope, seededWalkScope, seedWalkScope()), and the ScopeOps clone mirror resolves the renamed properties - including the previously missed seeded reference, which a native clone must reset like every other per-instance memo. The Fiber class-not-found ignore for sub-8.1 self-analysis is obsolete now that nothing references Fiber.
…e boundary only toMutatingScope() returns $this and stays as a deprecated alias - extensions (phpstan-doctrine's OtherMethodQueryBuilderParser) call it. resetPerFileAnalysisState() moves from processNodes() to the per-file callers (FileAnalyser, TypeInferenceTestCase): extensions start nested processNodes() walks mid-file - phpstan-doctrine parsing a query-builder method, rule tooling re-analysing a callee - and each wipe forced the outer file to rebuild its per-file caches, re-converging closure types and recomputing narrowing memos. On shipmonk's test files, where warm reflection state makes those rebuilds expensive and query-builder consults are frequent, whole analysis-order windows ran 2x slower than 2.2.x while the same files in isolation were near parity.
Rules and collectors re-ask the same nodes across a callback batch, and the walk scope's resolvedTypes memo used to answer those repeats in O(1) before the callback-facing scope existed. Every repeat paid the stored-result guard - variable-state compares, node-key printing on re-priced asks - which shipmonk's rule set (disallowed-calls formatting every call, the dead-code collectors) multiplied into whole test-file windows running twice as slow as 2.2.x. The entry pins the asked node: a dropped synthetic's object id can be reused by the next synthetic, and the identity check rejects the stale hit.
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
August 17, 2026 19:22
0d9fa98 to
12e97e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for the "new world" where an expression is traversed once: after
processExpr, itsExpressionResultknows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementingTypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, theTypeSpecifierdispatcher) are guarded behindNewWorld::disableOldWorld()and get mass-deleted in PHPStan 3.0.What's on the branch, bottom up:
ExpressionResultFactory: old-world type resolution entry points throw whenNewWorld::disableOldWorld()is flipped (the migration meter); allExpressionResultconstruction goes through a generated factory.ExpressionResultcarriesbeforeScope,expr,typeCallback,specifyTypesCallbackand is stored per node inExpressionResultStorage(layered O(1)duplicate()), replacing the stored before-Scope.ExprHandler/TypeResolvingExprHandlersplit:resolveType/specifyTypesmove to the sub-interface so handlers can shed them one by one.ExpressionResultStorageStack: old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers'resolveType) keep working for converted handlers' nodes. Every scope shares the stack created by its internal scope factory;NodeScopeResolverpushes the storage of the analysis in progress throughMutatingScope::pushExpressionResultStorage()(always popped infinally, throwing on imbalance), andMutatingScopeanswers from the stored result - or processes a synthetic node on demand. Scopes never reference a storage directly, so nothing pins the result graph with the cycle collector disabled inbin/phpstan. Also addsMutatingScope::applySpecifiedTypes-filterBySpecifiedTypeswithoutScope::getType().ScalarHandlerandArrayHandlerno longer implementTypeResolvingExprHandler. The array migration is a precision win the old world cannot reach: each item type is captured at its own evaluation point, so[$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c]infersarray{1, 2, 1, 3, 1, 2}.Verified: full test suite green,
make phpstanclean, and analysis memory back at baseline (no leak from the result graph despitegc_disable()).Closes phpstan/phpstan#13944
Closes phpstan/phpstan#12207
Closes phpstan/phpstan#7155
Closes phpstan/phpstan#14396
Closes phpstan/phpstan#11953
Closes phpstan/phpstan#12780
🤖 Generated with Claude Code
Closes phpstan/phpstan#14999
Closes phpstan/phpstan#13334
Closes phpstan/phpstan#15004